# frozen_string_literal: true

require "listen"
require "fileutils"
require "autospec/reload_css"
require "autospec/base_runner"
require "socket_server"

module Autospec
end

class Autospec::Manager
  def self.run(opts = {})
    self.new(opts).run
  end

  def initialize(opts = {})
    @opts = opts
    @debug = opts[:debug]
    @auto_run_all = ENV["AUTO_RUN_ALL"] != "0"
    @queue = []
    @mutex = Mutex.new
    @signal = ConditionVariable.new
    @runners = [ruby_runner]
  end

  def run
    Signal.trap("HUP") do
      stop_runners
      exit
    end

    Signal.trap("INT") do
      begin
        stop_runners
      rescue => e
        puts "FAILED TO STOP RUNNERS #{e}"
      end
      exit
    end

    ensure_all_specs_will_run if @auto_run_all
    start_runners
    start_service_queue
    listen_for_changes

    puts "Press [ENTER] to stop the current run"
    puts "Press [ENTER] while stopped to run all specs" unless @auto_run_all
    while @runners.any?(&:running?)
      STDIN.gets
      process_queue
    end
  rescue => e
    fail(e, "failed in run")
  ensure
    stop_runners
  end

  private

  def ruby_runner
    require "autospec/simple_runner"
    Autospec::SimpleRunner.new
  end

  def javascript_runner
    require "autospec/qunit_runner"
    Autospec::QunitRunner.new
  end

  def ensure_all_specs_will_run(current_runner = nil)
    puts "@@@@@@@@@@@@ ensure_all_specs_will_run" if @debug

    @queue.reject! { |_, s, _| s == "spec" }

    @queue.concat [["spec", "spec", current_runner]] if current_runner

    @runners.each do |runner|
      unless @queue.any? { |_, s, r| s == "spec" && r == runner }
        @queue.concat [["spec", "spec", runner]]
      end
    end
  end

  %i[start stop abort].each do |verb|
    define_method("#{verb}_runners") do
      puts "@@@@@@@@@@@@ #{verb}_runners" if @debug
      @runners.each(&verb)
    end
  end

  def start_service_queue
    puts "@@@@@@@@@@@@ start_service_queue" if @debug
    Thread.new { thread_loop while true }
  end

  # the main loop, will run the specs in the queue till one fails or the queue is empty
  def thread_loop
    puts "@@@@@@@@@@@@ thread_loop" if @debug
    @mutex.synchronize do
      current = @queue.first
      last_failed = false
      last_failed = process_spec(current) if current
      # stop & wait for the queue to have at least one item or when there's been a failure
      if @debug
        puts "@@@@@@@@@@@@ waiting because..."
        puts "@@@@@@@@@@@@ ...current spec has failed" if last_failed
        puts "@@@@@@@@@@@@ ...queue is empty" if @queue.length == 0
      end
      @signal.wait(@mutex) if @queue.length == 0 || last_failed
    end
  rescue => e
    fail(e, "failed in main loop")
  end

  # will actually run the spec and check whether the spec has failed or not
  def process_spec(current)
    puts "@@@@@@@@@@@@ process_spec --> #{current}" if @debug
    has_failed = false
    # retrieve the instance of the runner
    runner = current[2]
    # actually run the spec (blocking call)
    result = runner.run(current[1]).to_i

    if result == 0
      puts "@@@@@@@@@@@@ success" if @debug
      # remove the spec from the queue
      @queue.shift
    else
      puts "@@@@@@@@@@@@ failure" if @debug
      has_failed = true
      if result > 0
        focus_on_failed_tests(current)
        ensure_all_specs_will_run(runner) if @auto_run_all
      end
    end

    has_failed
  end

  def focus_on_failed_tests(current)
    puts "@@@@@@@@@@@@ focus_on_failed_tests --> #{current}" if @debug
    runner = current[2]
    # we only want 1 focus in the queue
    @queue.shift if current[0] == "focus"
    # focus on the first 10 failed specs
    failed_specs = runner.failed_specs[0..10]
    puts "@@@@@@@@@@@@ failed_specs --> #{failed_specs}" if @debug

    # try focus tag
    if failed_specs.length > 0
      filename, _ = failed_specs[0].split(":")
      if filename && File.exist?(filename) && !File.directory?(filename)
        spec = File.read(filename)
        start, _ = spec.split(/\S*#focus\S*\z/)
        if start.length < spec.length
          line = start.scan(/\n/).length + 1
          puts "Found #focus tag on line #{line}!"
          failed_specs = ["#{filename}:#{line + 1}"]
        end
      end
    end

    # focus on the failed specs
    @queue.unshift ["focus", failed_specs.join(" "), runner] if failed_specs.length > 0
  end

  def root_path
    root_path ||= File.expand_path(File.dirname(__FILE__) + "../../..")
  end

  def reverse_symlink_map
    map = {}
    Dir[root_path + "/plugins/*"].each do |f|
      next if !File.directory? f
      resolved = File.realpath(f)
      map[resolved] = f if resolved != f
    end
    map
  end

  # plugins can be symlinked, try to figure out which plugin this is
  def reverse_symlink(file)
    resolved = file
    @reverse_map ||= reverse_symlink_map
    @reverse_map.each do |location, discourse_location|
      resolved = discourse_location + file[location.length..-1] if file.start_with?(location)
    end

    resolved
  end

  def listen_for_changes
    puts "@@@@@@@@@@@@ listen_for_changes" if @debug

    options = { ignore: %r{\Alib/autospec} }

    if @opts[:force_polling]
      options[:force_polling] = true
      options[:latency] = @opts[:latency] || 3
    end

    path = root_path

    if ENV["VIM_AUTOSPEC"]
      STDERR.puts "Using VIM file listener"

      socket_path = (Rails.root + "tmp/file_change.sock").to_s
      FileUtils.rm_f(socket_path)
      server = SocketServer.new(socket_path)
      server.start do |line|
        file, line = line.split(" ")
        file = reverse_symlink(file)
        file = file.sub(Rails.root.to_s + "/", "")
        # process_change can acquire a mutex and block
        # the acceptor
        Thread.new do
          if file =~ /(es6|js)\z/
            process_change([[file]])
          else
            process_change([[file, line]])
          end
        end
        "OK"
      end
      return
    end

    # to speed up boot we use a thread
    %w[spec lib app config test vendor plugins].each do |watch|
      puts "@@@@@@@@@ Listen to #{path}/#{watch} #{options}" if @debug
      Thread.new do
        begin
          listener =
            Listen.to("#{path}/#{watch}", options) do |modified, added, _|
              paths = [modified, added].flatten
              paths.compact!
              paths.map! do |long|
                long = reverse_symlink(long)
                long[(path.length + 1)..-1]
              end
              process_change(paths)
            end
          listener.start
          sleep
        rescue => e
          puts "FAILED to listen on changes to #{path}/#{watch}"
          puts e
        end
      end
    end
  end

  def process_change(files)
    return if files.length == 0

    puts "@@@@@@@@@@@@ process_change --> #{files}" if @debug

    specs = []
    hit = false

    files.each do |file, line|
      @runners.each do |runner|
        # reloaders
        runner.reloaders.each do |k|
          if k.match(file)
            puts "@@@@@@@@@@@@ #{file} matched a reloader for #{runner}" if @debug
            runner.reload
            # rubocop:disable Lint/NonLocalExitFromIterator
            return
            # rubocop:enable Lint/NonLocalExitFromIterator
          end
        end
        # watchers
        runner.watchers.each do |k, v|
          if m = k.match(file)
            puts "@@@@@@@@@@@@ #{file} matched a watcher for #{runner}" if @debug
            hit = true
            spec = v ? (v.arity == 1 ? v.call(m) : v.call) : file
            with_line = spec
            with_line = spec + ":" << line.to_s if spec == file && line
            if File.exist?(spec) || Dir.exist?(spec)
              specs << [file, spec, runner] if with_line != spec
              specs << [file, with_line, runner]
            end
          end
        end
      end
    end
    queue_specs(specs) if hit
  rescue => e
    fail(e, "failed in watcher")
  end

  def queue_specs(specs)
    puts "@@@@@@@@@@@@ queue_specs --> #{specs}" if @debug

    if specs.length == 0
      locked = @mutex.try_lock
      if locked
        @signal.signal
        @mutex.unlock
      end
      return
    else
      abort_runners
    end

    puts "@@@@@@@@@@@@ waiting for the mutex" if @debug
    @mutex.synchronize do
      puts "@@@@@@@@@@@@ queueing specs" if @debug
      puts "@@@@@@@@@@@@ #{@queue}" if @debug
      specs.each do |file, spec, runner|
        # make sure there's no other instance of this spec in the queue
        @queue.delete_if { |_, s, r| s.strip.start_with?(spec.strip) && r == runner }
        # deal with focused specs
        if @queue.first && @queue.first[0] == "focus"
          focus = @queue.shift
          @queue.unshift([file, spec, runner])
          if spec.exclude?(":") || focus[1].exclude?(spec.split(":")[0])
            @queue.unshift(focus) if focus[1].include?(spec) || file != spec
          end
        else
          @queue.unshift([file, spec, runner])
        end

        # push run all specs to end of queue in correct order
        ensure_all_specs_will_run(runner) if @auto_run_all
      end
      puts "@@@@@@@@@@@@ specs queued" if @debug
      puts "@@@@@@@@@@@@ #{@queue}" if @debug
      @signal.signal
    end
  end

  def process_queue
    puts "@@@@@@@@@@@@ process_queue" if @debug
    if @queue.length == 0
      puts "@@@@@@@@@@@@ queue is empty..." if @debug
      ensure_all_specs_will_run
      @signal.signal
    else
      current = @queue.first
      runner = current[2]
      specs = runner.failed_specs
      puts
      puts
      if specs.length == 0
        puts "No specs have failed yet! Aborting anyway"
        puts
        abort_runners
      else
        puts "The following specs have failed:"
        specs.each { |s| puts s }
        puts
        specs = specs.map { |s| [s, s, runner] }
        queue_specs(specs)
      end
    end
  end

  def fail(exception, message = nil)
    puts message if message
    puts exception.message
    puts exception.backtrace.join("\n")
  end
end
